indexResourceHook.ts ➔ defaultEntityEndpoint   A
last analyzed

Complexity

Conditions 2

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 6
c 0
b 0
f 0
dl 0
loc 11
rs 10
cc 2
1
import {
2
  Reducer,
3
  useCallback,
4
  useEffect,
5
  useMemo,
6
  useReducer,
7
  useRef,
8
} from "react";
9
import {
10
  deleteRequest,
11
  FetchError,
12
  getRequest,
13
  postRequest,
14
  processJsonResponse,
15
  putRequest,
16
} from "../../helpers/httpRequests";
17
import { getId, hasKey, identity } from "../../helpers/queries";
18
import indexCrudReducer, {
19
  initializeState,
20
  ResourceState,
21
  ActionTypes,
22
  AsyncAction,
23
} from "./indexCrudReducer";
24
import { Json, ResourceStatus } from "./types";
25
26
type IndexedObject<T> =
27
  | {
28
      [key: string]: T;
29
    }
30
  | { [key: number]: T };
31
32
function valuesSelector<T>(state: ResourceState<T>): IndexedObject<T> {
33
  return Object.entries(state.values).reduce(
34
    (collection: IndexedObject<T>, [key, item]) => {
35
      collection[key] = item.value;
36
      return collection;
37
    },
38
    {},
39
  );
40
}
41
function statusSelector<T>(
42
  state: ResourceState<T>,
43
): IndexedObject<ResourceStatus> {
44
  // If the entire index is being refreshed, then each individual item should be considered "pending".
45
  const forcePending = state.indexMeta.status === "pending";
46
  return Object.entries(state.values).reduce(
47
    (collection: IndexedObject<ResourceStatus>, [key, item]) => {
48
      collection[key] = forcePending ? "pending" : item.status;
49
      return collection;
50
    },
51
    {},
52
  );
53
}
54
55
// Defining these functions outside of the hook, despite their simplicity,
56
// so they remain constant between re-renders.
57
58
function defaultEntityEndpoint(baseEndpoint: string, entity: any): string {
59
  if (!hasKey(entity, "id")) {
60
    throw new Error(
61
      'Cannot use default resolveEntityEndpoint on item without an "id" property',
62
    );
63
  }
64
  return `${baseEndpoint}/${entity.id}`;
65
}
66
67
function defaultCreateEndpoint(baseEndpoint: string): string {
68
  return baseEndpoint;
69
}
70
71
function defaultKeyFn(item: any): number {
72
  if (!hasKey(item, "id")) {
73
    throw new Error(
74
      'Cannot use default keyFn on item without an "id" property',
75
    );
76
  }
77
  return getId(item);
78
}
79
80
function doNothing(): void {
81
  /* do nothing */
82
}
83
84
// The value dispatched to the reducer must have an id, or the reducer cannot place it correctly.
85
function isValidEntity(value: any): boolean {
86
  return hasKey(value, "id");
87
}
88
89
function isValidEntityList(value: any): boolean {
90
  return Array.isArray(value) && value.every(isValidEntity);
91
}
92
93
export const UNEXPECTED_FORMAT_ERROR =
94
  "Response from server was not expected format";
95
96
/**
97
 * This hook keeps a local list of entities in sync with a REST api representing a single resource.
98
 *
99
 * *** Interaction with API ***
100
 * The API can be interacted with in 4 ways:
101
 *   - Refreshing, getting a complete list of entities
102
 *   - Creating a new entity that is added to the list
103
 *   - Updating a specific entity in the list
104
 *   - Deleting a specific entity in the list
105
 *
106
 * The only required argument is the API endpoint. By default, the CRUD operations (Create, Read, Update, Delete) follow normal REST conventions:
107
 *   - Create submits a POST request to endpoint
108
 *   - Refresh submits a GET request to endpoint
109
 *   - Update submits a PUT request to endpoint/id
110
 *   - Delete submits a DELETE request to endpoint/id
111
 * The urls used may be modified by overriding resolveEntityEndpoint (for update and delete requests) and resolveCreateEndpoint.
112
 * This may allow, for example, for using query parameters in some of these urls.
113
 * Note: The HTTP verbs used cannot be changed.
114
 *
115
 * The api requests MUST return valid JSON (except for delete requests), or the request will be considered to have failed.
116
 * The response to refresh requests must be a JSON List, and update and create requests must return the resulting entity as a json object.
117
 * However, the JSON objects may be preprocessed before being stored locally, by overriding parseEntityResponse and/or parseIndexResponse.
118
 *
119
 * *** Hook Values and Statuses *** *
120
 * values: This represents the list of entities compressing the resource.
121
 *   Note: values is not an array. It is an object with each entity indexed by id, so specific items may be retrieved more easily.
122
 *   Note: By default, values starts out empty and the hook immediately triggers a refresh callback.
123
 *   Note: An initialValue may be provided, which suppresses that initial refresh, unless forceInitialRefresh is ALSO overridden with true.
124
 *   Note: Setting forceInitialRefresh to false has no effect. Set initialValue to [] instead.
125
 *
126
 * indexStatus, createStatus, and entityStatus: These tell you whether any requests are currently in progress, and if not whether the last completed request was successful.
127
 *   Note: entityStatus covers both update and delete requests, and contains a status value for each entity, indexed by id.
128
 *
129
 * *** Callbacks *** *
130
 * create, refresh, update, deleteResource: These callbacks trigger requests to the api, updating values and the status properties accordingly.
131
 *   Note: while conventional "reactive" programming would control UI based only on the current values and status properties, these callbacks also return promises
132
 *         which allow code to respond to the success or failure of specific requests.
133
 *
134
 * *** Error Handling ***
135
 * You may watch for statuses of "rejected" to determining whether an error occurred during certain requests.
136
 * To respond to any details of potential errors, override handleError.
137
 * Note: If the error was caused by a non-200 response from the server, handleError will receive an instance of FetchError, which contains the entire response object.
138
 */
139
export function useResourceIndex<T>(
140
  endpoint: string, // API endpoint that returns a list of T.
141
  overrides?: {
142
    initialValue?: T[]; // Defaults to an empty list.
143
    skipInitialRefresh?: boolean; // Defaults to false. Override if you want to keep the initialValue until refresh is called manually.
144
    parseEntityResponse?: (response: Json) => T; // Defaults to the identity function.
145
    parseIndexResponse?: (response: Json) => T[]; // Defaults to (response) => response.map(parseEntityResponse)
146
    resolveEntityEndpoint?: (baseEndpoint: string, entity: T) => string; // Defaults to appending '/id' to baseEndpoint. Used for update (PUT) and delete (DELETE) requests.
147
    resolveCreateEndpoint?: (baseEndpoint: string, newEntity: T) => string; // Defaults to identical to endpoint. Used for create (POST) requests.
148
    handleError?: (error: Error | FetchError) => void;
149
    keyFn?: (item: T) => string | number; // Returns a unique key for each item. Defaults to using `item.id`.
150
  },
151
): {
152
  values: IndexedObject<T>;
153
  initialRefreshFinished: boolean; // If an initial refresh happens, this becomes true after is fulfilled or rejected. If initial fetch is skipped, this will be true immediately.
154
  indexStatus: ResourceStatus; // The state of any requests to reload the entire index.
155
  createStatus: ResourceStatus; // If ANY create requests are in progress, this is 'pending'. Otherwise, it is 'fulfilled' or 'rejected' depending on the last request to complete.
156
  entityStatus: IndexedObject<ResourceStatus>; // Note that if indexStatus is 'pending', every entity status will also be 'pending'.
157
  create: (newValue: T) => Promise<T>;
158
  refresh: () => Promise<T[]>; // Reloads the entire index.
159
  update: (newValue: T) => Promise<T>;
160
  deleteResource: (value: T) => Promise<void>;
161
} {
162
  const initialValue = overrides?.initialValue ?? [];
163
  const doInitialRefresh = overrides?.skipInitialRefresh !== true;
164
  const parseEntityResponse = overrides?.parseEntityResponse ?? identity;
165
  const parseIndexResponse = useMemo(
166
    () =>
167
      overrides?.parseIndexResponse ??
168
      ((response: Json): T[] => response.map(parseEntityResponse)),
169
    [overrides?.parseIndexResponse, parseEntityResponse],
170
  );
171
  const resolveEntityEndpoint =
172
    overrides?.resolveEntityEndpoint ?? defaultEntityEndpoint;
173
  const resolveCreateEndpoint =
174
    overrides?.resolveCreateEndpoint ?? defaultCreateEndpoint;
175
  const handleError = overrides?.handleError ?? doNothing;
176
  const keyFn = overrides?.keyFn ?? defaultKeyFn;
177
178
  const addKey = useCallback(
179
    (item: T): { item: T; key: string | number } => {
180
      return { item, key: keyFn(item) };
181
    },
182
    [keyFn],
183
  );
184
185
  const isSubscribed = useRef(true);
186
187
  const [state, dispatch] = useReducer<
188
    Reducer<ResourceState<T>, AsyncAction<T>>
189
  >(
190
    indexCrudReducer,
191
    initializeState(initialValue.map(addKey), doInitialRefresh),
192
  );
193
194
  const values = useMemo(() => valuesSelector(state), [state]);
195
  const { initialRefreshFinished } = state.indexMeta;
196
  const indexStatus = state.indexMeta.status;
197
  const createStatus = state.createMeta.status;
198
  const entityStatus = useMemo(() => statusSelector(state), [state]);
199
200
  const create = useCallback(
201
    async (newValue: T): Promise<T> => {
202
      dispatch({
203
        type: ActionTypes.CreateStart,
204
        meta: { item: newValue },
205
      });
206
      let entity: T;
207
      try {
208
        const json = await postRequest(
209
          resolveCreateEndpoint(endpoint, newValue),
210
          newValue,
211
        ).then(processJsonResponse);
212
        entity = parseEntityResponse(json);
213
        if (!isValidEntity(entity)) {
214
          throw new Error(UNEXPECTED_FORMAT_ERROR);
215
        }
216
      } catch (error) {
217
        if (isSubscribed.current) {
218
          dispatch({
219
            type: ActionTypes.CreateReject,
220
            payload: error,
221
            meta: { item: newValue },
222
          });
223
          handleError(error);
224
        }
225
        throw error;
226
      }
227
      if (isSubscribed.current) {
228
        dispatch({
229
          type: ActionTypes.CreateFulfill,
230
          payload: addKey(entity),
231
          meta: { item: newValue },
232
        });
233
      }
234
      return entity;
235
    },
236
    [endpoint, resolveCreateEndpoint, parseEntityResponse, handleError, addKey],
237
  );
238
239
  const refresh = useCallback(async (): Promise<T[]> => {
240
    dispatch({
241
      type: ActionTypes.IndexStart,
242
    });
243
    let index: T[];
244
    try {
245
      const json = await getRequest(endpoint).then(processJsonResponse);
246
      index = parseIndexResponse(json);
247
      if (!isValidEntityList(index)) {
248
        throw new Error(UNEXPECTED_FORMAT_ERROR);
249
      }
250
    } catch (error) {
251
      if (isSubscribed.current) {
252
        dispatch({
253
          type: ActionTypes.IndexReject,
254
          payload: error,
255
        });
256
        handleError(error);
257
      }
258
      throw error;
259
    }
260
    if (isSubscribed.current) {
261
      dispatch({
262
        type: ActionTypes.IndexFulfill,
263
        payload: index.map(addKey),
264
      });
265
    }
266
    return index;
267
  }, [endpoint, parseIndexResponse, handleError, addKey]);
268
269
  const update = useCallback(
270
    async (newValue: T): Promise<T> => {
271
      const meta = addKey(newValue);
272
      dispatch({
273
        type: ActionTypes.UpdateStart,
274
        meta,
275
      });
276
      let value: T;
277
      try {
278
        const json = await putRequest(
279
          resolveEntityEndpoint(endpoint, newValue),
280
          newValue,
281
        ).then(processJsonResponse);
282
        value = parseEntityResponse(json);
283
        if (!isValidEntity(value)) {
284
          throw new Error(UNEXPECTED_FORMAT_ERROR);
285
        }
286
      } catch (error) {
287
        if (isSubscribed.current) {
288
          dispatch({
289
            type: ActionTypes.UpdateReject,
290
            payload: error,
291
            meta,
292
          });
293
          handleError(error);
294
        }
295
        throw error;
296
      }
297
      if (isSubscribed.current) {
298
        dispatch({
299
          type: ActionTypes.UpdateFulfill,
300
          payload: value,
301
          meta,
302
        });
303
      }
304
      return value;
305
    },
306
    [endpoint, resolveEntityEndpoint, parseEntityResponse, handleError, addKey],
307
  );
308
309
  const deleteResource = useCallback(
310
    async (entity: T): Promise<void> => {
311
      const meta = { key: keyFn(entity) };
312
      dispatch({
313
        type: ActionTypes.DeleteStart,
314
        meta,
315
      });
316
      try {
317
        const response = await deleteRequest(
318
          resolveEntityEndpoint(endpoint, entity),
319
        );
320
        if (!response.ok) {
321
          throw new FetchError(response);
322
        }
323
      } catch (error) {
324
        if (isSubscribed.current) {
325
          dispatch({
326
            type: ActionTypes.DeleteReject,
327
            payload: error,
328
            meta,
329
          });
330
          handleError(error);
331
        }
332
        throw error;
333
      }
334
      if (isSubscribed.current) {
335
        dispatch({
336
          type: ActionTypes.DeleteFulfill,
337
          meta,
338
        });
339
      }
340
    },
341
    [endpoint, resolveEntityEndpoint, handleError, keyFn],
342
  );
343
344
  // Despite the usual guidelines, this should only be reconsidered if endpoint changes.
345
  // Changing doInitialRefresh after the first run (or refresh) should not cause this to rerun.
346
  useEffect(() => {
347
    if (doInitialRefresh) {
348
      refresh().catch(doNothing);
349
    }
350
351
    // Unsubscribe from promises when this hook is unmounted.
352
    return (): void => {
353
      isSubscribed.current = false;
354
    };
355
356
    // eslint-disable-next-line react-hooks/exhaustive-deps
357
  }, [endpoint]);
358
359
  return {
360
    values,
361
    initialRefreshFinished,
362
    indexStatus,
363
    createStatus,
364
    entityStatus,
365
    create,
366
    refresh,
367
    update,
368
    deleteResource,
369
  };
370
}
371
372
export default useResourceIndex;
373